diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/FileOrdering.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/FileOrdering.kt new file mode 100644 index 0000000..13481fe --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/FileOrdering.kt @@ -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.orderedByAddedAtDesc(): List = sortedByDescending { it.addedAt } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt new file mode 100644 index 0000000..679b72c --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt @@ -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)) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerViewModel.kt new file mode 100644 index 0000000..14ac894 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerViewModel.kt @@ -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>(emptyList()) + val documents: StateFlow> = _documents.asStateFlow() + + init { + viewModelScope.launch { + fileRepository.observeAllVisible().collect { files -> + _documents.value = files.orderedByAddedAtDesc() + } + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt index 571167a..1043271 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt @@ -46,6 +46,7 @@ fun FolderDetailScreen( folderResourceId: String, onBack: () -> Unit, onOpenFolder: (String) -> Unit, + onOpenDocument: (String) -> Unit, viewModel: FolderDetailViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -80,6 +81,7 @@ fun FolderDetailScreen( isRefreshing = uiState.isRefreshing, error = uiState.error, onOpenFolder = onOpenFolder, + onOpenDocument = onOpenDocument, modifier = Modifier.padding(padding), ) } @@ -92,6 +94,7 @@ private fun FolderDetailContent( isRefreshing: Boolean, error: String?, onOpenFolder: (String) -> Unit, + onOpenDocument: (String) -> Unit, modifier: Modifier = Modifier, ) { val empty = subFolders.isEmpty() && files.isEmpty() && !isRefreshing @@ -105,7 +108,7 @@ private fun FolderDetailContent( FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) }) } items(files, key = { it.resourceId }) { file -> - FileRow(file) + FileRow(file, onClick = { onOpenDocument(file.resourceId) }) } when { empty -> { @@ -176,8 +179,9 @@ private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) { } @Composable -private fun FileRow(file: FileEntity) { +private fun FileRow(file: FileEntity, onClick: () -> Unit) { Card( + onClick = onClick, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), ) { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index 51ee412..d99daed 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -60,6 +60,7 @@ import java.util.Locale fun FolderListScreen( selectedTab: NavTab, onTabSelected: (NavTab) -> Unit, + onOpenDocument: (String) -> Unit, viewModel: FolderListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -101,6 +102,7 @@ fun FolderListScreen( FolderListContent( uiState = uiState, onAddFolder = { pickFolderLauncher.launch(null) }, + onOpenDocument = onOpenDocument, modifier = Modifier.padding(padding), ) } @@ -125,6 +127,7 @@ private fun Uri.displayName(context: Context): String? = runCatching { private fun FolderListContent( uiState: FolderListUiState, onAddFolder: () -> Unit, + onOpenDocument: (String) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -193,9 +196,17 @@ private fun FolderListContent( .padding(bottom = 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) { - FileCard(file = row.right, modifier = Modifier.weight(1f)) + FileCard( + file = row.right, + onClick = { onOpenDocument(row.right.resourceId) }, + modifier = Modifier.weight(1f), + ) } else { 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 -private fun FileCard(file: FileEntity, modifier: Modifier = Modifier) { +private fun FileCard(file: FileEntity, onClick: () -> Unit, modifier: Modifier = Modifier) { Card( + onClick = onClick, colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), border = BorderStroke(1.dp, Color(0xFFEAEAEA)), diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt index 1d50831..ab4326c 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -8,6 +8,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.vaultdrop.mobile.ui.document.DocumentViewerScreen import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen import com.vaultdrop.mobile.ui.folderlist.FolderListScreen import com.vaultdrop.mobile.ui.search.SearchScreen @@ -19,8 +20,11 @@ object Routes { const val SETTINGS = "settings" const val FOLDER_DETAIL = "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 document(documentResourceId: String): String = "document/$documentResourceId" } private fun String?.toNavTab(): NavTab = when (this) { @@ -54,6 +58,7 @@ fun NavGraph() { FolderListScreen( selectedTab = selectedTab, onTabSelected = onTabSelected, + onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, ) } composable(Routes.SEARCH) { @@ -81,6 +86,21 @@ fun NavGraph() { folderResourceId = folderId, onBack = { navController.popBackStack() }, 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() }, ) } } diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index dddd31f..b673d2a 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -14,6 +14,14 @@ Refresh Back + + Document + Document reader coming soon + Size + Type + Added on + Modified on + Files Search diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 8349df7..d604149 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -15,6 +15,14 @@ Actualiser Retour + + Document + Lecteur de documents à venir + Taille + Type + Ajouté le + Modifié le + Fichiers Recherche