multi select and generate pdf

This commit is contained in:
m
2026-09-12 11:33:33 +02:00
parent 9d44703400
commit 3a232138bd
6 changed files with 319 additions and 41 deletions
@@ -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,35 +57,70 @@ 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 = {
IconButton(onClick = onBack) { if (selection.active) {
Icon( IconButton(onClick = { selection.clear() }) {
Icons.AutoMirrored.Filled.ArrowBack, Icon(
contentDescription = stringResource(R.string.back), Icons.Filled.Close,
) contentDescription = stringResource(R.string.selection_cancel),
)
}
} else {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
} }
}, },
actions = { actions = {
ServerStatusBadge( if (selection.active) {
status = connectionStatus, IconButton(
onClick = connectionStatusViewModel::checkNow, onClick = {
) val ids = selection.ids.toList()
IconButton(onClick = viewModel::refresh) { selection.clear()
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh)) onBuildPdf(ids)
},
enabled = selection.ids.isNotEmpty(),
) {
Icon(
Icons.Filled.MergeType,
contentDescription = stringResource(R.string.selection_assemble),
)
}
} else {
ServerStatusBadge(
status = connectionStatus,
onClick = connectionStatusViewModel::checkNow,
)
IconButton(onClick = viewModel::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)
}
} }
} }
} }
@@ -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,17 +100,51 @@ 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 = {
ServerStatusBadge( if (selection.active) {
status = connectionStatus, IconButton(
onClick = connectionStatusViewModel::checkNow, onClick = {
) val ids = selection.ids.toList()
IconButton(onClick = viewModel::refresh) { selection.clear()
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh)) onBuildPdf(ids)
},
enabled = selection.ids.isNotEmpty(),
) {
Icon(
Icons.Filled.MergeType,
contentDescription = stringResource(R.string.selection_assemble),
)
}
} else {
ServerStatusBadge(
status = connectionStatus,
onClick = connectionStatusViewModel::checkNow,
)
IconButton(onClick = viewModel::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)) {
FileCategoryIcon(file = file, size = 36.dp) Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
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 = {
ServerStatusBadge( if (selection.active) {
status = connectionStatus, IconButton(
onClick = connectionStatusViewModel::checkNow, 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(
status = connectionStatus,
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,11 +338,15 @@ private fun SearchResultCard(file: FileEntity, onClick: () -> Unit, modifier: Mo
) )
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Text( if (selection.active) {
text = formatSize(file.size), SelectionStatusIcon(selected = selected)
style = MaterialTheme.typography.labelMedium, } else {
color = MaterialTheme.colorScheme.onSurfaceVariant, Text(
) text = formatSize(file.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
} }
} }
@@ -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>