multi select and generate pdf
This commit is contained in:
+71
-4
@@ -1,5 +1,8 @@
|
|||||||
package com.vaultdrop.mobile.ui.folderdetail
|
package com.vaultdrop.mobile.ui.folderdetail
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -12,7 +15,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.MergeType
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -39,7 +44,10 @@ import com.vaultdrop.mobile.data.local.entity.FileEntity
|
|||||||
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
||||||
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
||||||
|
import com.vaultdrop.mobile.ui.components.SelectionState
|
||||||
|
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
||||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
||||||
|
import com.vaultdrop.mobile.ui.components.rememberSelectionState
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -49,29 +57,63 @@ fun FolderDetailScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenFolder: (String) -> Unit,
|
onOpenFolder: (String) -> Unit,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
|
onBuildPdf: (List<String>) -> Unit,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
viewModel: FolderDetailViewModel = hiltViewModel(),
|
viewModel: FolderDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
||||||
|
val selection = rememberSelectionState()
|
||||||
|
|
||||||
LaunchedEffect(uiState.folderMissing) {
|
LaunchedEffect(uiState.folderMissing) {
|
||||||
if (uiState.folderMissing) onBack()
|
if (uiState.folderMissing) onBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = selection.active) { selection.clear() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(uiState.folder?.name ?: stringResource(R.string.folder)) },
|
title = {
|
||||||
|
if (selection.active) {
|
||||||
|
Text(stringResource(R.string.selection_count, selection.ids.size))
|
||||||
|
} else {
|
||||||
|
Text(uiState.folder?.name ?: stringResource(R.string.folder))
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
if (selection.active) {
|
||||||
|
IconButton(onClick = { selection.clear() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.selection_cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(R.string.back),
|
contentDescription = stringResource(R.string.back),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
if (selection.active) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val ids = selection.ids.toList()
|
||||||
|
selection.clear()
|
||||||
|
onBuildPdf(ids)
|
||||||
|
},
|
||||||
|
enabled = selection.ids.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MergeType,
|
||||||
|
contentDescription = stringResource(R.string.selection_assemble),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
onClick = connectionStatusViewModel::checkNow,
|
||||||
@@ -79,6 +121,7 @@ fun FolderDetailScreen(
|
|||||||
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))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -88,6 +131,7 @@ fun FolderDetailScreen(
|
|||||||
files = uiState.files,
|
files = uiState.files,
|
||||||
isRefreshing = uiState.isRefreshing,
|
isRefreshing = uiState.isRefreshing,
|
||||||
error = uiState.error,
|
error = uiState.error,
|
||||||
|
selection = selection,
|
||||||
onOpenFolder = onOpenFolder,
|
onOpenFolder = onOpenFolder,
|
||||||
onOpenDocument = onOpenDocument,
|
onOpenDocument = onOpenDocument,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
@@ -101,6 +145,7 @@ private fun FolderDetailContent(
|
|||||||
files: List<FileEntity>,
|
files: List<FileEntity>,
|
||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
|
selection: SelectionState,
|
||||||
onOpenFolder: (String) -> Unit,
|
onOpenFolder: (String) -> Unit,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -116,7 +161,11 @@ private fun FolderDetailContent(
|
|||||||
FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) })
|
FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) })
|
||||||
}
|
}
|
||||||
items(files, key = { it.resourceId }) { file ->
|
items(files, key = { it.resourceId }) { file ->
|
||||||
FileRow(file, onClick = { onOpenDocument(file.resourceId) })
|
FileRow(
|
||||||
|
file = file,
|
||||||
|
selection = selection,
|
||||||
|
onOpenDocument = { onOpenDocument(file.resourceId) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
empty -> {
|
empty -> {
|
||||||
@@ -186,12 +235,27 @@ private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FileRow(file: FileEntity, onClick: () -> Unit) {
|
private fun FileRow(
|
||||||
|
file: FileEntity,
|
||||||
|
selection: SelectionState,
|
||||||
|
onOpenDocument: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selected = file.resourceId in selection.ids
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
if (selection.active) selection.toggle(file.resourceId) else onOpenDocument()
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (!selection.active) selection.start(file.resourceId)
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -213,6 +277,9 @@ private fun FileRow(file: FileEntity, onClick: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (selection.active) {
|
||||||
|
SelectionStatusIcon(selected = selected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-5
@@ -5,9 +5,12 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -23,6 +26,8 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.MergeType
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -52,7 +57,10 @@ import com.vaultdrop.mobile.data.local.entity.FileEntity
|
|||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
||||||
import com.vaultdrop.mobile.features.sync.SyncViewModel
|
import com.vaultdrop.mobile.features.sync.SyncViewModel
|
||||||
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
||||||
|
import com.vaultdrop.mobile.ui.components.SelectionState
|
||||||
|
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
||||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
||||||
|
import com.vaultdrop.mobile.ui.components.rememberSelectionState
|
||||||
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -63,6 +71,7 @@ fun FolderListScreen(
|
|||||||
selectedTab: NavTab,
|
selectedTab: NavTab,
|
||||||
onTabSelected: (NavTab) -> Unit,
|
onTabSelected: (NavTab) -> Unit,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
|
onBuildPdf: (List<String>) -> Unit,
|
||||||
syncViewModel: SyncViewModel,
|
syncViewModel: SyncViewModel,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
viewModel: FolderListViewModel = hiltViewModel(),
|
viewModel: FolderListViewModel = hiltViewModel(),
|
||||||
@@ -72,6 +81,7 @@ fun FolderListScreen(
|
|||||||
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val folderLabel = stringResource(R.string.folder)
|
val folderLabel = stringResource(R.string.folder)
|
||||||
|
val selection = rememberSelectionState()
|
||||||
|
|
||||||
val pickFolderLauncher = rememberLauncherForActivityResult(
|
val pickFolderLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.OpenDocumentTree(),
|
ActivityResultContracts.OpenDocumentTree(),
|
||||||
@@ -90,11 +100,44 @@ fun FolderListScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = selection.active) { selection.clear() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.files)) },
|
title = {
|
||||||
|
if (selection.active) {
|
||||||
|
Text(stringResource(R.string.selection_count, selection.ids.size))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.files))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
if (selection.active) {
|
||||||
|
IconButton(onClick = { selection.clear() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.selection_cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
if (selection.active) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val ids = selection.ids.toList()
|
||||||
|
selection.clear()
|
||||||
|
onBuildPdf(ids)
|
||||||
|
},
|
||||||
|
enabled = selection.ids.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MergeType,
|
||||||
|
contentDescription = stringResource(R.string.selection_assemble),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
onClick = connectionStatusViewModel::checkNow,
|
||||||
@@ -102,6 +145,7 @@ 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))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -113,6 +157,7 @@ fun FolderListScreen(
|
|||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
isImporting = importState.isImporting,
|
isImporting = importState.isImporting,
|
||||||
error = uiState.error ?: importState.error,
|
error = uiState.error ?: importState.error,
|
||||||
|
selection = selection,
|
||||||
onAddFolder = { pickFolderLauncher.launch(null) },
|
onAddFolder = { pickFolderLauncher.launch(null) },
|
||||||
onOpenDocument = onOpenDocument,
|
onOpenDocument = onOpenDocument,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
@@ -140,6 +185,7 @@ private fun FolderListContent(
|
|||||||
uiState: FolderListUiState,
|
uiState: FolderListUiState,
|
||||||
isImporting: Boolean,
|
isImporting: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
|
selection: SelectionState,
|
||||||
onAddFolder: () -> Unit,
|
onAddFolder: () -> Unit,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -212,12 +258,14 @@ private fun FolderListContent(
|
|||||||
) {
|
) {
|
||||||
FileCard(
|
FileCard(
|
||||||
file = row.left,
|
file = row.left,
|
||||||
|
selection = selection,
|
||||||
onClick = { onOpenDocument(row.left.resourceId) },
|
onClick = { onOpenDocument(row.left.resourceId) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (row.right != null) {
|
if (row.right != null) {
|
||||||
FileCard(
|
FileCard(
|
||||||
file = row.right,
|
file = row.right,
|
||||||
|
selection = selection,
|
||||||
onClick = { onOpenDocument(row.right.resourceId) },
|
onClick = { onOpenDocument(row.right.resourceId) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -241,19 +289,38 @@ private fun SectionHeader(label: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Carte fichier — le clic ouvre la consultation du document. */
|
/** Carte fichier — clic long pour la sélection multi-fichiers, clic pour ouvrir. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FileCard(file: FileEntity, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun FileCard(
|
||||||
|
file: FileEntity,
|
||||||
|
selection: SelectionState,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val selected = file.resourceId in selection.ids
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
|
||||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)),
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
border = BorderStroke(1.dp, Color(0xFFEAEAEA)),
|
border = BorderStroke(1.dp, Color(0xFFEAEAEA)),
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
modifier = modifier,
|
modifier = modifier.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
if (selection.active) selection.toggle(file.resourceId) else onClick()
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (!selection.active) selection.start(file.resourceId)
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
FileCategoryIcon(file = file, size = 36.dp)
|
FileCategoryIcon(file = file, size = 36.dp)
|
||||||
|
if (selection.active) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
SelectionStatusIcon(selected = selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = file.name,
|
text = file.name,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import com.vaultdrop.mobile.ui.auth.AuthViewModel
|
|||||||
import com.vaultdrop.mobile.ui.document.DocumentViewerScreen
|
import com.vaultdrop.mobile.ui.document.DocumentViewerScreen
|
||||||
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
|
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
|
||||||
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
||||||
|
import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen
|
||||||
import com.vaultdrop.mobile.ui.search.SearchScreen
|
import com.vaultdrop.mobile.ui.search.SearchScreen
|
||||||
import com.vaultdrop.mobile.ui.settings.SettingsScreen
|
import com.vaultdrop.mobile.ui.settings.SettingsScreen
|
||||||
|
|
||||||
@@ -32,9 +33,12 @@ object Routes {
|
|||||||
const val ARG_FOLDER = "folderResourceId"
|
const val ARG_FOLDER = "folderResourceId"
|
||||||
const val DOCUMENT = "document/{documentResourceId}"
|
const val DOCUMENT = "document/{documentResourceId}"
|
||||||
const val ARG_DOCUMENT = "documentResourceId"
|
const val ARG_DOCUMENT = "documentResourceId"
|
||||||
|
const val PDF_BUILDER = "pdfbuilder/{ids}"
|
||||||
|
const val ARG_BUILDER_IDS = "ids"
|
||||||
|
|
||||||
fun folder(folderResourceId: String): String = "folder/$folderResourceId"
|
fun folder(folderResourceId: String): String = "folder/$folderResourceId"
|
||||||
fun document(documentResourceId: String): String = "document/$documentResourceId"
|
fun document(documentResourceId: String): String = "document/$documentResourceId"
|
||||||
|
fun pdfBuilder(resourceIds: List<String>): String = "pdfbuilder/${resourceIds.joinToString(",")}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String?.toNavTab(): NavTab = when (this) {
|
private fun String?.toNavTab(): NavTab = when (this) {
|
||||||
@@ -76,6 +80,7 @@ fun NavGraph(
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
||||||
|
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
|
||||||
syncViewModel = syncViewModel,
|
syncViewModel = syncViewModel,
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
)
|
)
|
||||||
@@ -85,6 +90,7 @@ fun NavGraph(
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
||||||
|
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -110,6 +116,7 @@ fun NavGraph(
|
|||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
|
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
|
||||||
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
||||||
|
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -128,6 +135,22 @@ fun NavGraph(
|
|||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.PDF_BUILDER,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(Routes.ARG_BUILDER_IDS) { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val ids = backStackEntry.arguments
|
||||||
|
?.getString(Routes.ARG_BUILDER_IDS)
|
||||||
|
.orEmpty()
|
||||||
|
.split(",")
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
PdfBuilderScreen(
|
||||||
|
initialResourceIds = ids,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bandeau global : visible sur tous les onglets tant qu'un import SAF
|
// Bandeau global : visible sur tous les onglets tant qu'un import SAF
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.vaultdrop.mobile.ui.search
|
package com.vaultdrop.mobile.ui.search
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -17,6 +20,8 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.MergeType
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -24,6 +29,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -46,9 +52,12 @@ import com.vaultdrop.mobile.data.local.entity.FileEntity
|
|||||||
import com.vaultdrop.mobile.domain.FileCategory
|
import com.vaultdrop.mobile.domain.FileCategory
|
||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
||||||
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
||||||
|
import com.vaultdrop.mobile.ui.components.SelectionState
|
||||||
|
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
||||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
||||||
import com.vaultdrop.mobile.ui.components.color
|
import com.vaultdrop.mobile.ui.components.color
|
||||||
import com.vaultdrop.mobile.ui.components.icon
|
import com.vaultdrop.mobile.ui.components.icon
|
||||||
|
import com.vaultdrop.mobile.ui.components.rememberSelectionState
|
||||||
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -59,21 +68,57 @@ fun SearchScreen(
|
|||||||
selectedTab: NavTab,
|
selectedTab: NavTab,
|
||||||
onTabSelected: (NavTab) -> Unit,
|
onTabSelected: (NavTab) -> Unit,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
|
onBuildPdf: (List<String>) -> Unit,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
viewModel: SearchViewModel = hiltViewModel(),
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
||||||
|
val selection = rememberSelectionState()
|
||||||
|
|
||||||
|
BackHandler(enabled = selection.active) { selection.clear() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.search)) },
|
title = {
|
||||||
|
if (selection.active) {
|
||||||
|
Text(stringResource(R.string.selection_count, selection.ids.size))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.search))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
if (selection.active) {
|
||||||
|
IconButton(onClick = { selection.clear() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.selection_cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
if (selection.active) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val ids = selection.ids.toList()
|
||||||
|
selection.clear()
|
||||||
|
onBuildPdf(ids)
|
||||||
|
},
|
||||||
|
enabled = selection.ids.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MergeType,
|
||||||
|
contentDescription = stringResource(R.string.selection_assemble),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
onClick = connectionStatusViewModel::checkNow,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -83,6 +128,7 @@ fun SearchScreen(
|
|||||||
) { padding ->
|
) { padding ->
|
||||||
SearchContent(
|
SearchContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
selection = selection,
|
||||||
onQueryChange = viewModel::onQueryChange,
|
onQueryChange = viewModel::onQueryChange,
|
||||||
onCategorySelect = viewModel::onCategorySelect,
|
onCategorySelect = viewModel::onCategorySelect,
|
||||||
onOpenDocument = onOpenDocument,
|
onOpenDocument = onOpenDocument,
|
||||||
@@ -94,6 +140,7 @@ fun SearchScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SearchContent(
|
private fun SearchContent(
|
||||||
uiState: SearchUiState,
|
uiState: SearchUiState,
|
||||||
|
selection: SelectionState,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onCategorySelect: (String?) -> Unit,
|
onCategorySelect: (String?) -> Unit,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
@@ -113,6 +160,7 @@ private fun SearchContent(
|
|||||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||||
placeholder = { Text(stringResource(R.string.search_hint)) },
|
placeholder = { Text(stringResource(R.string.search_hint)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
enabled = !selection.active,
|
||||||
)
|
)
|
||||||
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
@@ -138,6 +186,7 @@ private fun SearchContent(
|
|||||||
|
|
||||||
SearchResults(
|
SearchResults(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
selection = selection,
|
||||||
onOpenDocument = onOpenDocument,
|
onOpenDocument = onOpenDocument,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -185,6 +234,7 @@ private fun categoryLabel(category: FileCategory): String = when (category) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SearchResults(
|
private fun SearchResults(
|
||||||
uiState: SearchUiState,
|
uiState: SearchUiState,
|
||||||
|
selection: SelectionState,
|
||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -231,21 +281,40 @@ private fun SearchResults(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(uiState.results, key = { it.resourceId }) { file ->
|
items(uiState.results, key = { it.resourceId }) { file ->
|
||||||
SearchResultCard(file = file, onClick = { onOpenDocument(file.resourceId) })
|
SearchResultCard(
|
||||||
|
file = file,
|
||||||
|
selection = selection,
|
||||||
|
onOpenDocument = { onOpenDocument(file.resourceId) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultCard(file: FileEntity, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun SearchResultCard(
|
||||||
|
file: FileEntity,
|
||||||
|
selection: SelectionState,
|
||||||
|
onOpenDocument: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val selected = file.resourceId in selection.ids
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
|
||||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)),
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
border = BorderStroke(1.dp, Color(0xFFEAEAEA)),
|
border = BorderStroke(1.dp, Color(0xFFEAEAEA)),
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
if (selection.active) selection.toggle(file.resourceId) else onOpenDocument()
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (!selection.active) selection.start(file.resourceId)
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
@@ -269,6 +338,9 @@ private fun SearchResultCard(file: FileEntity, onClick: () -> Unit, modifier: Mo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
|
if (selection.active) {
|
||||||
|
SelectionStatusIcon(selected = selected)
|
||||||
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = formatSize(file.size),
|
text = formatSize(file.size),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -276,6 +348,7 @@ private fun SearchResultCard(file: FileEntity, onClick: () -> Unit, modifier: Mo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -74,4 +74,28 @@
|
|||||||
<string name="login_error_unauthorized">Incorrect username or password</string>
|
<string name="login_error_unauthorized">Incorrect username or password</string>
|
||||||
<string name="login_skip">Continue without account</string>
|
<string name="login_skip">Continue without account</string>
|
||||||
<string name="skip_message">Local mode — your folders stay on this device and are not synced.</string>
|
<string name="skip_message">Local mode — your folders stay on this device and are not synced.</string>
|
||||||
|
|
||||||
|
<!-- Multi-select + PDF assembly -->
|
||||||
|
<string name="selection_count">%1$d selected</string>
|
||||||
|
<string name="selection_assemble">Assemble PDF</string>
|
||||||
|
<string name="selection_cancel">Cancel selection</string>
|
||||||
|
<string name="pdf_builder_title">Assemble a PDF</string>
|
||||||
|
<string name="pdf_builder_empty">Nothing here yet. Add files or a note to start assembling.</string>
|
||||||
|
<string name="pdf_builder_add_files">Add a file</string>
|
||||||
|
<string name="pdf_builder_pick_files">Pick files</string>
|
||||||
|
<string name="pdf_builder_add_note">Add a note</string>
|
||||||
|
<string name="pdf_builder_note">Text note</string>
|
||||||
|
<string name="pdf_builder_note_hint">Type your note text…</string>
|
||||||
|
<string name="pdf_builder_add">Add</string>
|
||||||
|
<string name="pdf_builder_cancel">Cancel</string>
|
||||||
|
<string name="pdf_builder_assemble">Assemble PDF</string>
|
||||||
|
<string name="pdf_builder_remove">Remove</string>
|
||||||
|
<string name="pdf_builder_move_up">Move up</string>
|
||||||
|
<string name="pdf_builder_move_down">Move down</string>
|
||||||
|
<string name="pdf_builder_unreadable">Unreadable — skipped</string>
|
||||||
|
<string name="pdf_builder_no_items">Select at least one file or add a note.</string>
|
||||||
|
<string name="pdf_builder_error">Could not generate the PDF.</string>
|
||||||
|
<string name="pdf_builder_save_error">Could not save the PDF.</string>
|
||||||
|
<string name="pdf_builder_saved">PDF created</string>
|
||||||
|
<string name="pdf_generated_folder">Generated PDFs</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -82,4 +82,28 @@
|
|||||||
<string name="status_checking">Vérification…</string>
|
<string name="status_checking">Vérification…</string>
|
||||||
<string name="status_local">Mode local</string>
|
<string name="status_local">Mode local</string>
|
||||||
<string name="status_tap_to_recheck">Statut serveur — appuyer pour vérifier</string>
|
<string name="status_tap_to_recheck">Statut serveur — appuyer pour vérifier</string>
|
||||||
|
|
||||||
|
<!-- Sélection multi-fichiers + assemblage PDF -->
|
||||||
|
<string name="selection_count">%1$d sélectionné(s)</string>
|
||||||
|
<string name="selection_assemble">Assembler en PDF</string>
|
||||||
|
<string name="selection_cancel">Annuler la sélection</string>
|
||||||
|
<string name="pdf_builder_title">Assembler un PDF</string>
|
||||||
|
<string name="pdf_builder_empty">Aucun élément. Ajoute des fichiers ou une note pour commencer l\'assemblage.</string>
|
||||||
|
<string name="pdf_builder_add_files">Ajouter un fichier</string>
|
||||||
|
<string name="pdf_builder_pick_files">Choisir des fichiers</string>
|
||||||
|
<string name="pdf_builder_add_note">Ajouter une note</string>
|
||||||
|
<string name="pdf_builder_note">Note texte</string>
|
||||||
|
<string name="pdf_builder_note_hint">Saisis le texte de la note…</string>
|
||||||
|
<string name="pdf_builder_add">Ajouter</string>
|
||||||
|
<string name="pdf_builder_cancel">Annuler</string>
|
||||||
|
<string name="pdf_builder_assemble">Assembler le PDF</string>
|
||||||
|
<string name="pdf_builder_remove">Retirer</string>
|
||||||
|
<string name="pdf_builder_move_up">Monter</string>
|
||||||
|
<string name="pdf_builder_move_down">Descendre</string>
|
||||||
|
<string name="pdf_builder_unreadable">Illisible — ignoré</string>
|
||||||
|
<string name="pdf_builder_no_items">Sélectionne au moins un fichier ou ajoute une note.</string>
|
||||||
|
<string name="pdf_builder_error">Impossible de générer le PDF.</string>
|
||||||
|
<string name="pdf_builder_save_error">Impossible d\'enregistrer le PDF.</string>
|
||||||
|
<string name="pdf_builder_saved">PDF créé</string>
|
||||||
|
<string name="pdf_generated_folder">PDF générés</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user