share folders and files
This commit is contained in:
+27
@@ -58,4 +58,31 @@ interface PendingOperationDao {
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'")
|
||||
suspend fun countPending(): Int
|
||||
|
||||
/**
|
||||
* Une op `move_resource` encore en attente de push existe-t-elle pour cette
|
||||
* ressource ? L'antichambre outbox est alors la source de vérité du
|
||||
* placement : ni le refresh serveur ni le walk SAF ne doivent écraser le
|
||||
* `folder_resource_id` local avant que le serveur ait accusé le move.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM pending_operations
|
||||
WHERE resource_id = :resourceId
|
||||
AND operation = 'move_resource'
|
||||
AND status = 'pending'
|
||||
""")
|
||||
suspend fun countPendingMoveOperations(resourceId: String): Int
|
||||
|
||||
/**
|
||||
* Un `move_resource` (pending ou synced) existe-t-il pour cette ressource ?
|
||||
* La réconciliation du walk ne doit jamais masquer (`exists = 0`) une ligne
|
||||
* en cours de transition vers une autre arborescence physique.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM pending_operations
|
||||
WHERE resource_id = :resourceId
|
||||
AND operation = 'move_resource'
|
||||
AND status IN ('pending', 'synced')
|
||||
""")
|
||||
suspend fun countMoveOperations(resourceId: String): Int
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
|
||||
@@ -67,6 +68,10 @@ class ApiClient @Inject constructor(
|
||||
suspend fun syncOps(operations: List<SyncOpDto>): SyncOpsResult =
|
||||
unwrap({ apiService.syncOps(SyncOpsRequest(operations)) })
|
||||
|
||||
/** Résout un destinataire par username EXACT — 404 si inconnu. */
|
||||
suspend fun resolveUser(username: String): ResolvedUserDto =
|
||||
unwrap({ apiService.resolveUser(username.trim()) })
|
||||
|
||||
/** Snapshot des permissions effectives (delta si `after` ms fourni). */
|
||||
suspend fun syncPermissions(after: Long? = null): List<ResourcePermissionDto> =
|
||||
unwrap({ apiService.syncPermissions(after) })
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
|
||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult
|
||||
@@ -51,6 +52,12 @@ interface ApiService {
|
||||
@Body body: SyncOpsRequest,
|
||||
): Response<ApiEnvelope<SyncOpsResult>>
|
||||
|
||||
/** Résolution d'un destinataire par username EXACT (jamais d'énumération). */
|
||||
@GET("users/resolve")
|
||||
suspend fun resolveUser(
|
||||
@Query("username") username: String,
|
||||
): Response<ApiEnvelope<ResolvedUserDto>>
|
||||
|
||||
/** Snapshot des permissions effectives (delta si `after` fourni, ms epoch). */
|
||||
@GET("sync/permissions")
|
||||
suspend fun syncPermissions(
|
||||
|
||||
@@ -68,6 +68,12 @@ data class UserDto(
|
||||
@Json(name = "is_admin") val isAdmin: Boolean = false,
|
||||
)
|
||||
|
||||
/** Réponse de `GET /users/resolve` — `{ id, username }` uniquement. */
|
||||
data class ResolvedUserDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "username") val username: String,
|
||||
)
|
||||
|
||||
/** Miroir de `api/types.ts` — `LoginRequest`. */
|
||||
data class LoginRequestDto(
|
||||
@Json(name = "username") val username: String,
|
||||
@@ -121,4 +127,6 @@ data class ResourcePermissionDto(
|
||||
@Json(name = "expiresAt") val expiresAt: Any? = null,
|
||||
@Json(name = "cachedAt") val cachedAt: Long,
|
||||
@Json(name = "updatedAt") val updatedAt: Long,
|
||||
@Json(name = "name") val name: String = "",
|
||||
@Json(name = "parentId") val parentId: String? = null,
|
||||
)
|
||||
+16
-1
@@ -130,12 +130,18 @@ class FileRepository @Inject constructor(
|
||||
|
||||
val category = computeCategory(input.mimeType, input.extension).dbValue
|
||||
|
||||
// Un `move_resource` encore en attente fait de la cible outbox la
|
||||
// source de vérité du placement : un snapshot SAF périmé (walk lancé
|
||||
// avant le move) ne doit pas ré-attribuer le dossier à l'ancien parent.
|
||||
val movePending = existing != null && outboxRepository.hasPendingMoveOperation(existing.resourceId)
|
||||
val effectiveFolder = if (movePending) existing.folderResourceId else folderResourceId
|
||||
|
||||
val entity = FileEntity(
|
||||
id = existing?.id ?: 0L,
|
||||
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
|
||||
uri = input.uri,
|
||||
name = input.name,
|
||||
folderResourceId = folderResourceId,
|
||||
folderResourceId = effectiveFolder,
|
||||
extension = input.extension ?: existing?.extension,
|
||||
size = input.size,
|
||||
mimeType = input.mimeType,
|
||||
@@ -192,12 +198,21 @@ class FileRepository @Inject constructor(
|
||||
/**
|
||||
* `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de
|
||||
* chaque fichier. Ne supprime jamais de lignes locales.
|
||||
*
|
||||
* Garde-fou outbox : un fichier dont le `move_resource` n'a pas encore été
|
||||
* poussé garde son placement local. Le serveur renvoie encore l'ancien
|
||||
* dossier tant que l'op est pendante — écraser la ligne la ferait disparaître
|
||||
* du dossier cible (et réapparaître dans l'ancien).
|
||||
*/
|
||||
suspend fun refreshFromServer(folderResourceId: String) {
|
||||
val files = apiClient.listFiles(folderId = folderResourceId, pageSize = PAGE_SIZE)
|
||||
if (files.isEmpty()) return
|
||||
val now = System.currentTimeMillis()
|
||||
files.forEach { dto ->
|
||||
if (outboxRepository.hasPendingMoveOperation(dto.id)) {
|
||||
// L'antichambre outbox fait foi tant que le move n'est pas synced.
|
||||
return@forEach
|
||||
}
|
||||
fileDao.upsert(toEntity(dto, folderResourceId, now))
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -103,6 +103,41 @@ class OutboxRepository @Inject constructor(
|
||||
resourceType = resourceType,
|
||||
)
|
||||
|
||||
/**
|
||||
* Raccourci `share` — payload `{ granteeUserId, access }` (voir
|
||||
* docs/api-v1.md §6.1). `access`: viewer | commenter | editor.
|
||||
*/
|
||||
suspend fun enqueueShare(
|
||||
resourceId: String,
|
||||
resourceType: String,
|
||||
granteeUserId: String,
|
||||
access: String,
|
||||
inherit: Boolean = true,
|
||||
expiresAt: Long? = null,
|
||||
): String = enqueue(
|
||||
operation = PendingOperationType.SHARE,
|
||||
resourceId = resourceId,
|
||||
resourceType = resourceType,
|
||||
payload = buildMap {
|
||||
put("granteeUserId", granteeUserId)
|
||||
put("access", access)
|
||||
put("inherit", inherit)
|
||||
expiresAt?.let { put("expiresAt", it) }
|
||||
},
|
||||
)
|
||||
|
||||
/** Raccourci `revoke_share` — payload `{ granteeUserId }`. */
|
||||
suspend fun enqueueRevokeShare(
|
||||
resourceId: String,
|
||||
resourceType: String,
|
||||
granteeUserId: String,
|
||||
): String = enqueue(
|
||||
operation = PendingOperationType.REVOKE_SHARE,
|
||||
resourceId = resourceId,
|
||||
resourceType = resourceType,
|
||||
payload = mapOf("granteeUserId" to granteeUserId),
|
||||
)
|
||||
|
||||
/** Nombre d'ops en attente de push (stats UI optionnelles). */
|
||||
suspend fun countPending(): Int = pendingOperationDao.countPending()
|
||||
|
||||
@@ -113,4 +148,21 @@ class OutboxRepository @Inject constructor(
|
||||
*/
|
||||
suspend fun hasCreateOperation(resourceId: String): Boolean =
|
||||
pendingOperationDao.countCreateOperations(resourceId) > 0
|
||||
|
||||
/**
|
||||
* Un `move_resource` est-il encore en attente de push ? Tant que l'op est
|
||||
* pendante, le `folder_resource_id` local reflète la cible future : ni le
|
||||
* refresh serveur (qui renvoie l'ancien dossier) ni le walk SAF (snapshot
|
||||
* périmé) ne doivent l'écraser.
|
||||
*/
|
||||
suspend fun hasPendingMoveOperation(resourceId: String): Boolean =
|
||||
pendingOperationDao.countPendingMoveOperations(resourceId) > 0
|
||||
|
||||
/**
|
||||
* Un `move_resource` (pending ou synced) a-t-il jamais été journalisé ?
|
||||
* Utilisé par la réconciliation du walk : une ligne en transition physique
|
||||
* ne doit pas être masquée (`exists = 0`) avant convergence.
|
||||
*/
|
||||
suspend fun hasMoveOperation(resourceId: String): Boolean =
|
||||
pendingOperationDao.countMoveOperations(resourceId) > 0
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.local.AppDatabase
|
||||
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||
import com.vaultdrop.mobile.data.repository.FileRepository
|
||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||
import com.vaultdrop.mobile.data.repository.OutboxRepository
|
||||
import com.vaultdrop.mobile.data.repository.SaveFileInput
|
||||
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
||||
import com.vaultdrop.mobile.domain.DeviceIdentity
|
||||
@@ -34,6 +35,7 @@ class DeviceSync @Inject constructor(
|
||||
private val scanner: SafScanner,
|
||||
private val folderRepository: FolderRepository,
|
||||
private val fileRepository: FileRepository,
|
||||
private val outboxRepository: OutboxRepository,
|
||||
private val deviceIdentity: DeviceIdentity,
|
||||
) {
|
||||
|
||||
@@ -119,7 +121,13 @@ class DeviceSync @Inject constructor(
|
||||
}
|
||||
for (file in fileRepository.getAll()) {
|
||||
val fileUri = file.uri
|
||||
if (file.exists != 0 && fileUri != null && isChildOf(fileUri, rootUri) && fileUri !in seen) {
|
||||
// Un `move_resource` (pending ou synced) fait de l'outbox la
|
||||
// source de vérité du placement : on ne masque jamais une ligne
|
||||
// en transition vers une autre arborescence physique (le
|
||||
// snapshot peut être périmé par rapport au move en cours).
|
||||
if (file.exists != 0 && fileUri != null && isChildOf(fileUri, rootUri) && fileUri !in seen &&
|
||||
!outboxRepository.hasMoveOperation(file.resourceId)
|
||||
) {
|
||||
fileRepository.markMissing(file.resourceId, now)
|
||||
missing++
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vaultdrop.mobile.data.local.entity.PendingOpStatus
|
||||
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
|
||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
||||
import com.vaultdrop.mobile.data.repository.ShareRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -46,6 +47,7 @@ class SyncViewModel @Inject constructor(
|
||||
private val deviceSync: DeviceSync,
|
||||
private val folderRepository: FolderRepository,
|
||||
private val pendingOperationDao: PendingOperationDao,
|
||||
private val shareRepository: ShareRepository,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -107,6 +109,10 @@ class SyncViewModel @Inject constructor(
|
||||
OutboxSyncWorker.enqueue(appContext)
|
||||
}
|
||||
.onFailure { e -> Timber.w(e, "syncAll failed, retrying later") }
|
||||
// Hydrate les ressources partagées depuis le snapshot serveur.
|
||||
// Échec réseau toléré : le prochain tick réessaiera.
|
||||
runCatching { shareRepository.syncSnapshot() }
|
||||
.onFailure { e -> Timber.d("syncSnapshot failed, retrying later: %s", e.message) }
|
||||
_walkInProgress.value = false
|
||||
delay(INTERVAL_MS)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.vaultdrop.mobile.auth.SessionManager
|
||||
import com.vaultdrop.mobile.data.remote.ApiClient
|
||||
import com.vaultdrop.mobile.data.remote.ApiException
|
||||
import com.vaultdrop.mobile.data.repository.AuthRepository
|
||||
import com.vaultdrop.mobile.data.repository.ShareRepository
|
||||
import com.vaultdrop.mobile.features.sync.OutboxSyncWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -25,6 +26,7 @@ class AuthViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionManager: SessionManager,
|
||||
private val apiClient: ApiClient,
|
||||
private val shareRepository: ShareRepository,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -49,6 +51,11 @@ class AuthViewModel @Inject constructor(
|
||||
authRepository.registerDevice()
|
||||
// Session restaurée → drainer l'outbox laissée en attente.
|
||||
OutboxSyncWorker.enqueue(appContext)
|
||||
if (session != null) {
|
||||
// Snapshot complet des permissions partagées (convergence).
|
||||
runCatching { shareRepository.syncSnapshot() }
|
||||
.onFailure { e -> Timber.d("syncSnapshot on restore failed: %s", e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +73,9 @@ class AuthViewModel @Inject constructor(
|
||||
_authState.value = AuthState.SignedIn(response.user)
|
||||
// Connexion réussie → pousser les mutations locales en attente.
|
||||
OutboxSyncWorker.enqueue(appContext)
|
||||
// Snapshot complet des permissions partagées (convergence).
|
||||
runCatching { shareRepository.syncSnapshot() }
|
||||
.onFailure { e -> Timber.d("syncSnapshot on login failed: %s", e.message) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
val error = when (e) {
|
||||
|
||||
+28
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -65,6 +66,8 @@ import com.vaultdrop.mobile.ui.components.DeleteReview
|
||||
import com.vaultdrop.mobile.ui.components.DeleteWarningDialog
|
||||
import com.vaultdrop.mobile.ui.components.reviewDelete
|
||||
import com.vaultdrop.mobile.ui.navigation.SelectionNavBar
|
||||
import com.vaultdrop.mobile.ui.share.ShareBottomSheet
|
||||
import com.vaultdrop.mobile.ui.share.ShareViewModel
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -78,14 +81,17 @@ fun FolderDetailScreen(
|
||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||
syncViewModel: SyncViewModel,
|
||||
viewModel: FolderDetailViewModel = hiltViewModel(),
|
||||
shareViewModel: ShareViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
||||
val shareState by shareViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val selection = rememberSelectionState()
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var showMoveDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteWarning by remember { mutableStateOf(false) }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showShareSheet by remember { mutableStateOf(false) }
|
||||
var pendingDeleteMode by remember { mutableStateOf<FileDeleter.DeleteMode?>(null) }
|
||||
var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -172,6 +178,12 @@ fun FolderDetailScreen(
|
||||
status = connectionStatus,
|
||||
onClick = connectionStatusViewModel::checkNow,
|
||||
)
|
||||
IconButton(onClick = { showShareSheet = true }) {
|
||||
Icon(
|
||||
Icons.Filled.Share,
|
||||
contentDescription = stringResource(R.string.share_content_description),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = viewModel::refresh) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
|
||||
}
|
||||
@@ -280,6 +292,22 @@ fun FolderDetailScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showShareSheet) {
|
||||
ShareBottomSheet(
|
||||
resourceName = uiState.folder?.name ?: stringResource(R.string.folder),
|
||||
onDismiss = {
|
||||
showShareSheet = false
|
||||
shareViewModel.reset()
|
||||
},
|
||||
onShare = { username, access ->
|
||||
shareViewModel.share(folderResourceId, "folder", username, access)
|
||||
},
|
||||
sharing = shareState.sharing,
|
||||
error = shareState.error,
|
||||
enqueued = shareState.enqueued,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+84
-1
@@ -305,6 +305,7 @@ fun FolderListScreen(
|
||||
atRoot = uiState.browseFolderId == null,
|
||||
browseFolderName = uiState.browseFolderName,
|
||||
subFolders = uiState.browseSubFolders,
|
||||
browseFiles = uiState.browseFiles,
|
||||
sections = uiState.sections,
|
||||
isImporting = importState.isImporting,
|
||||
error = uiState.error ?: importState.error,
|
||||
@@ -467,6 +468,7 @@ private fun HomeViewContent(
|
||||
atRoot: Boolean,
|
||||
browseFolderName: String?,
|
||||
subFolders: List<FolderEntity>,
|
||||
browseFiles: List<FileEntity>,
|
||||
sections: List<FileSection>,
|
||||
isImporting: Boolean,
|
||||
error: String?,
|
||||
@@ -491,11 +493,14 @@ private fun HomeViewContent(
|
||||
atRoot = atRoot,
|
||||
browseFolderName = browseFolderName,
|
||||
subFolders = subFolders,
|
||||
browseFiles = browseFiles,
|
||||
isImporting = isImporting,
|
||||
error = error,
|
||||
selection = selection,
|
||||
onBrowseUp = onBrowseUp,
|
||||
onOpenBrowseFolder = onOpenBrowseFolder,
|
||||
onCreateFolder = onCreateFolder,
|
||||
onOpenDocument = onOpenDocument,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -592,13 +597,17 @@ private fun FolderBrowserContent(
|
||||
atRoot: Boolean,
|
||||
browseFolderName: String?,
|
||||
subFolders: List<FolderEntity>,
|
||||
browseFiles: List<FileEntity>,
|
||||
isImporting: Boolean,
|
||||
error: String?,
|
||||
selection: SelectionState,
|
||||
onBrowseUp: () -> Unit,
|
||||
onOpenBrowseFolder: (String) -> Unit,
|
||||
onCreateFolder: () -> Unit,
|
||||
onOpenDocument: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val empty = subFolders.isEmpty() && (moveMode || browseFiles.isEmpty()) && !isImporting
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -677,7 +686,20 @@ private fun FolderBrowserContent(
|
||||
FolderRow(folder, onClick = { onOpenBrowseFolder(folder.resourceId) })
|
||||
}
|
||||
|
||||
if (subFolders.isEmpty() && !isImporting) {
|
||||
// En mode déplacement, le navigateur sert au choix de la cible : les
|
||||
// fichiers du dossier courant ne sont pas affichés (reste un pur
|
||||
// explorateur de dossiers).
|
||||
if (!moveMode) {
|
||||
items(browseFiles, key = { it.resourceId }) { file ->
|
||||
BrowseFileRow(
|
||||
file = file,
|
||||
selection = selection,
|
||||
onOpenDocument = { onOpenDocument(file.resourceId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
text = stringResource(if (atRoot) R.string.no_folders_yet else R.string.empty_folder),
|
||||
@@ -856,6 +878,67 @@ private fun FileCard(
|
||||
}
|
||||
}
|
||||
|
||||
/** Carte fichier dans l'explorateur Dossiers — clic pour ouvrir, clic long pour la sélection. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun BrowseFileRow(
|
||||
file: FileEntity,
|
||||
selection: SelectionState,
|
||||
onOpenDocument: () -> Unit,
|
||||
) {
|
||||
val selected = file.resourceId in selection.ids
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 6.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (selection.active) selection.toggle(file.resourceId) else onOpenDocument()
|
||||
},
|
||||
onLongClick = {
|
||||
if (!selection.active) selection.start(file.resourceId)
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
FileCategoryIcon(file = file, size = 26.dp)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = formatSize(file.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (selection.active) {
|
||||
SelectionStatusIcon(selected = selected)
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilePendingReviewBadge(file = file)
|
||||
FileSyncStatusIcon(file = file, size = 20.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Miroir de `formatSize` (app/index.tsx). */
|
||||
@Composable
|
||||
private fun formatSize(bytes: Long): String {
|
||||
|
||||
+2
@@ -37,6 +37,8 @@ data class FolderListUiState(
|
||||
val browseFolderName: String? = null,
|
||||
/** Sous-dossiers visibles du dossier courant de l'explorateur. */
|
||||
val browseSubFolders: List<FolderEntity> = emptyList(),
|
||||
/** Fichiers visibles du dossier courant de l'explorateur. */
|
||||
val browseFiles: List<FileEntity> = emptyList(),
|
||||
val sections: List<FileSection> = emptyList(),
|
||||
val isRefreshing: Boolean = false,
|
||||
val error: String? = null,
|
||||
|
||||
+10
@@ -147,6 +147,16 @@ class FolderListViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(browseSubFolders = subFolders) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_defaultRootId.combine(_browseFolderId) { root, browse -> browse ?: root }
|
||||
.flatMapLatest { parentId ->
|
||||
if (parentId == null) flowOf(emptyList())
|
||||
else fileRepository.observeFiles(parentId)
|
||||
}
|
||||
.collect { files ->
|
||||
_uiState.update { it.copy(browseFiles = files) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Descend dans l'explorateur Dossiers (aussi en mode déplacement). */
|
||||
|
||||
@@ -229,4 +229,17 @@
|
||||
<string name="delete_confirm_button">Confirmer</string>
|
||||
<string name="delete_cancel">Annuler</string>
|
||||
<string name="delete_error">Erreur lors de la suppression</string>
|
||||
|
||||
<!-- Partage (share grants) -->
|
||||
<string name="share_title">Partager avec un utilisateur</string>
|
||||
<string name="share_username_hint">Nom d\'utilisateur du destinataire</string>
|
||||
<string name="share_access_label">Niveau d\'accès</string>
|
||||
<string name="share_access_viewer">Lecture (viewer)</string>
|
||||
<string name="share_access_commenter">Commentaire (commenter)</string>
|
||||
<string name="share_access_editor">Édition (editor)</string>
|
||||
<string name="share_submit">Partager</string>
|
||||
<string name="share_cancel">Annuler</string>
|
||||
<string name="share_error_not_found">Aucun utilisateur connu sous ce nom.</string>
|
||||
<string name="share_error_generic">Impossible de partager pour le moment.</string>
|
||||
<string name="share_content_description">Partager ce dossier</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user