preview document is working properly

This commit is contained in:
m
2026-09-11 17:02:36 +02:00
parent bca29ed26f
commit 2de3754ca9
8 changed files with 309 additions and 6 deletions
@@ -0,0 +1,6 @@
package com.vaultdrop.mobile.data.local
import com.vaultdrop.mobile.data.local.entity.FileEntity
/** Fil documents : même ordre que la grille d'accueil, du plus récent au plus vieux. */
fun List<FileEntity>.orderedByAddedAtDesc(): List<FileEntity> = sortedByDescending { it.addedAt }
@@ -0,0 +1,211 @@
package com.vaultdrop.mobile.ui.document
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* Consultation d'un document : titre + métadonnées, navigation au swipe entre
* les documents (même fil que l'écran d'accueil). Le lecteur sera intégré par
* la suite dans la zone centrale.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentViewerScreen(
initialResourceId: String,
onBack: () -> Unit,
viewModel: DocumentViewerViewModel = hiltViewModel(),
) {
val documents by viewModel.documents.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(pageCount = { documents.size })
// Démarre une seule fois sur le document demandé, dès que la liste est chargée.
var jumpPending by remember { mutableStateOf(true) }
LaunchedEffect(documents) {
if (jumpPending) {
val index = documents.indexOfFirst { it.resourceId == initialResourceId }
if (index >= 0) {
pagerState.scrollToPage(index)
jumpPending = false
}
}
}
val currentFile = documents.getOrNull(pagerState.currentPage)
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = currentFile?.name ?: stringResource(R.string.document),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
)
},
) { padding ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { page ->
val file = documents.getOrNull(page)
if (file != null) DocumentViewerPage(file)
}
}
}
@Composable
private fun DocumentViewerPage(file: FileEntity, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
) {
// Emplacement du futur lecteur de documents.
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(64.dp),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.document_reader_coming),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(modifier = Modifier
.fillMaxWidth()
.padding(16.dp)) {
MetadataRow(stringResource(R.string.document_size), formatSize(file.size))
MetadataRow(stringResource(R.string.document_type), fileTypeLabel(file))
MetadataRow(stringResource(R.string.document_added_at), formatDateTime(file.addedAt))
file.lastModified?.let { lastModified ->
MetadataRow(stringResource(R.string.document_modified_at), formatDateTime(lastModified))
}
}
}
}
}
@Composable
private fun MetadataRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
private fun fileTypeLabel(file: FileEntity): String =
file.extension?.takeIf { it.isNotBlank() }?.uppercase(Locale.getDefault())
?: file.mimeType?.uppercase(Locale.getDefault())
?: ""
private fun formatDateTime(millis: Long): String {
val locale = Locale.getDefault()
val dateTime = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime()
val day = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(dateTime)
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(dateTime)
return "$day $time"
}
/** Miroir de `formatSize` (app/index.tsx). */
@Composable
private fun formatSize(bytes: Long): String {
if (bytes < 1_024) {
return "$bytes ${stringResource(R.string.unit_bytes)}"
}
if (bytes < 1_024 * 1_024) {
val kb = bytes / 1_024f
return String.format(Locale.getDefault(), "%.1f %s", kb, stringResource(R.string.unit_kilobytes))
}
val mb = bytes / (1_024f * 1_024f)
return String.format(Locale.getDefault(), "%.1f %s", mb, stringResource(R.string.unit_megabytes))
}
@@ -0,0 +1,34 @@
package com.vaultdrop.mobile.ui.document
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.orderedByAddedAtDesc
import com.vaultdrop.mobile.data.repository.FileRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Consultation de documents : expose la liste complète des fichiers (même tri
* que l'accueil) pour naviguer au swipe d'un document à l'autre.
*/
@HiltViewModel
class DocumentViewerViewModel @Inject constructor(
private val fileRepository: FileRepository,
) : ViewModel() {
private val _documents = MutableStateFlow<List<FileEntity>>(emptyList())
val documents: StateFlow<List<FileEntity>> = _documents.asStateFlow()
init {
viewModelScope.launch {
fileRepository.observeAllVisible().collect { files ->
_documents.value = files.orderedByAddedAtDesc()
}
}
}
}
@@ -46,6 +46,7 @@ fun FolderDetailScreen(
folderResourceId: String, folderResourceId: String,
onBack: () -> Unit, onBack: () -> Unit,
onOpenFolder: (String) -> Unit, onOpenFolder: (String) -> Unit,
onOpenDocument: (String) -> Unit,
viewModel: FolderDetailViewModel = hiltViewModel(), viewModel: FolderDetailViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -80,6 +81,7 @@ fun FolderDetailScreen(
isRefreshing = uiState.isRefreshing, isRefreshing = uiState.isRefreshing,
error = uiState.error, error = uiState.error,
onOpenFolder = onOpenFolder, onOpenFolder = onOpenFolder,
onOpenDocument = onOpenDocument,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} }
@@ -92,6 +94,7 @@ private fun FolderDetailContent(
isRefreshing: Boolean, isRefreshing: Boolean,
error: String?, error: String?,
onOpenFolder: (String) -> Unit, onOpenFolder: (String) -> Unit,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val empty = subFolders.isEmpty() && files.isEmpty() && !isRefreshing val empty = subFolders.isEmpty() && files.isEmpty() && !isRefreshing
@@ -105,7 +108,7 @@ 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) FileRow(file, onClick = { onOpenDocument(file.resourceId) })
} }
when { when {
empty -> { empty -> {
@@ -176,8 +179,9 @@ private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
} }
@Composable @Composable
private fun FileRow(file: FileEntity) { private fun FileRow(file: FileEntity, onClick: () -> Unit) {
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),
) { ) {
@@ -60,6 +60,7 @@ import java.util.Locale
fun FolderListScreen( fun FolderListScreen(
selectedTab: NavTab, selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit, onTabSelected: (NavTab) -> Unit,
onOpenDocument: (String) -> Unit,
viewModel: FolderListViewModel = hiltViewModel(), viewModel: FolderListViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -101,6 +102,7 @@ fun FolderListScreen(
FolderListContent( FolderListContent(
uiState = uiState, uiState = uiState,
onAddFolder = { pickFolderLauncher.launch(null) }, onAddFolder = { pickFolderLauncher.launch(null) },
onOpenDocument = onOpenDocument,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} }
@@ -125,6 +127,7 @@ private fun Uri.displayName(context: Context): String? = runCatching {
private fun FolderListContent( private fun FolderListContent(
uiState: FolderListUiState, uiState: FolderListUiState,
onAddFolder: () -> Unit, onAddFolder: () -> Unit,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
@@ -193,9 +196,17 @@ private fun FolderListContent(
.padding(bottom = 12.dp), .padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
FileCard(file = row.left, modifier = Modifier.weight(1f)) FileCard(
file = row.left,
onClick = { onOpenDocument(row.left.resourceId) },
modifier = Modifier.weight(1f),
)
if (row.right != null) { if (row.right != null) {
FileCard(file = row.right, modifier = Modifier.weight(1f)) FileCard(
file = row.right,
onClick = { onOpenDocument(row.right.resourceId) },
modifier = Modifier.weight(1f),
)
} else { } else {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
} }
@@ -216,10 +227,11 @@ private fun SectionHeader(label: String) {
) )
} }
/** Carte fichier — miroir du `FileCard` de l'écran d'accueil JS (non cliquable). */ /** Carte fichier — le clic ouvre la consultation du document. */
@Composable @Composable
private fun FileCard(file: FileEntity, modifier: Modifier = Modifier) { private fun FileCard(file: FileEntity, onClick: () -> Unit, modifier: Modifier = Modifier) {
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)),
@@ -8,6 +8,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
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.search.SearchScreen import com.vaultdrop.mobile.ui.search.SearchScreen
@@ -19,8 +20,11 @@ object Routes {
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val FOLDER_DETAIL = "folder/{folderResourceId}" const val FOLDER_DETAIL = "folder/{folderResourceId}"
const val ARG_FOLDER = "folderResourceId" const val ARG_FOLDER = "folderResourceId"
const val DOCUMENT = "document/{documentResourceId}"
const val ARG_DOCUMENT = "documentResourceId"
fun folder(folderResourceId: String): String = "folder/$folderResourceId" fun folder(folderResourceId: String): String = "folder/$folderResourceId"
fun document(documentResourceId: String): String = "document/$documentResourceId"
} }
private fun String?.toNavTab(): NavTab = when (this) { private fun String?.toNavTab(): NavTab = when (this) {
@@ -54,6 +58,7 @@ fun NavGraph() {
FolderListScreen( FolderListScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
) )
} }
composable(Routes.SEARCH) { composable(Routes.SEARCH) {
@@ -81,6 +86,21 @@ fun NavGraph() {
folderResourceId = folderId, folderResourceId = folderId,
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)) },
)
}
composable(
route = Routes.DOCUMENT,
arguments = listOf(
navArgument(Routes.ARG_DOCUMENT) { type = NavType.StringType },
),
) { backStackEntry ->
val documentId = checkNotNull(
backStackEntry.arguments?.getString(Routes.ARG_DOCUMENT),
)
DocumentViewerScreen(
initialResourceId = documentId,
onBack = { navController.popBackStack() },
) )
} }
} }
@@ -14,6 +14,14 @@
<string name="refresh">Refresh</string> <string name="refresh">Refresh</string>
<string name="back">Back</string> <string name="back">Back</string>
<!-- Document viewer -->
<string name="document">Document</string>
<string name="document_reader_coming">Document reader coming soon</string>
<string name="document_size">Size</string>
<string name="document_type">Type</string>
<string name="document_added_at">Added on</string>
<string name="document_modified_at">Modified on</string>
<!-- Navigation --> <!-- Navigation -->
<string name="nav_files">Files</string> <string name="nav_files">Files</string>
<string name="nav_search">Search</string> <string name="nav_search">Search</string>
@@ -15,6 +15,14 @@
<string name="refresh">Actualiser</string> <string name="refresh">Actualiser</string>
<string name="back">Retour</string> <string name="back">Retour</string>
<!-- Consultation de documents -->
<string name="document">Document</string>
<string name="document_reader_coming">Lecteur de documents à venir</string>
<string name="document_size">Taille</string>
<string name="document_type">Type</string>
<string name="document_added_at">Ajouté le</string>
<string name="document_modified_at">Modifié le</string>
<!-- Navigation --> <!-- Navigation -->
<string name="nav_files">Fichiers</string> <string name="nav_files">Fichiers</string>
<string name="nav_search">Recherche</string> <string name="nav_search">Recherche</string>