From e272ecfb448918498f9c52bcfbe891b7fb3bb3b1 Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 09:46:09 +0200 Subject: [PATCH] add pdf viewer --- .../ui/document/DocumentViewerScreen.kt | 22 +-- .../ui/document/content/PdfPageViewer.kt | 159 ++++++++++++++++-- 2 files changed, 151 insertions(+), 30 deletions(-) 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 index afbc14b..d45f369 100644 --- 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 @@ -13,7 +13,6 @@ 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.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape @@ -34,7 +33,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -62,7 +60,7 @@ import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.ui.components.categoryValue import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer -import com.vaultdrop.mobile.ui.document.content.PdfPageViewer +import com.vaultdrop.mobile.ui.document.content.PdfFocusViewer import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId @@ -197,9 +195,9 @@ fun DocumentViewerScreen( } /** - * Lecture PDF focus : le document remplit l'écran (fond noir), un tap quitte le - * plein écran, le défilement vertical reste actif et une pastille indique la - * page courante. + * Lecture PDF focus : le document remplit l'écran (fond noir), une page par + * écran défiler verticalement, un tap quitte le plein écran et une pastille + * indique la page courante. */ @Composable private fun PdfFullscreenReader( @@ -208,10 +206,8 @@ private fun PdfFullscreenReader( onExitFullscreen: () -> Unit, modifier: Modifier = Modifier, ) { - val contentResolver = LocalContext.current.contentResolver - val listState = rememberLazyListState() var pageCount by remember(file.resourceId) { mutableIntStateOf(0) } - val currentPage by remember { derivedStateOf { listState.firstVisibleItemIndex + 1 } } + val pagerState = rememberPagerState(pageCount = { pageCount }) Box( modifier = modifier @@ -221,12 +217,12 @@ private fun PdfFullscreenReader( detectTapGestures(onTap = { onExitFullscreen() }) }, ) { - PdfPageViewer( + PdfFocusViewer( file = file, - contentResolver = contentResolver, + contentResolver = LocalContext.current.contentResolver, onOpenExternalFailed = onOpenExternalFailed, + pagerState = pagerState, modifier = Modifier.fillMaxSize(), - listState = listState, onPageCountChanged = { pageCount = it }, ) if (pageCount > 0) { @@ -238,7 +234,7 @@ private fun PdfFullscreenReader( color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), ) { Text( - text = "$currentPage / $pageCount", + text = "${pagerState.currentPage + 1} / $pageCount", modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.inverseOnSurface, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt index be7a640..aa56386 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt @@ -17,8 +17,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.VerticalPager import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material3.CircularProgressIndicator @@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -60,8 +61,6 @@ fun PdfPageViewer( contentResolver: ContentResolver, onOpenExternalFailed: () -> Unit, modifier: Modifier = Modifier, - listState: LazyListState = rememberLazyListState(), - onPageCountChanged: ((Int) -> Unit)? = null, ) { val uri = file.uri if (uri == null) { @@ -79,6 +78,77 @@ fun PdfPageViewer( onDispose { document.close() } } + LaunchedEffect(uri) { + pageCount = 0 + loadFailed = false + if (document.load()) { + pageCount = document.pageCount + } else { + loadFailed = true + } + } + + when { + loadFailed -> UnreadableDocument( + file = file, + message = stringResource(R.string.document_cannot_read), + onOpenExternalFailed = onOpenExternalFailed, + modifier = modifier, + ) + pageCount == 0 -> Box(modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + else -> BoxWithConstraints(modifier.fillMaxSize()) { + val density = LocalDensity.current + val maxWidthPx = with(density) { maxWidth.toPx().toInt() } + val maxHeightPx = with(density) { maxHeight.toPx().toInt() } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(pageCount, key = { it }) { page -> + PdfPageItem( + document = document, + page = page, + maxWidthPx = maxWidthPx, + maxHeightPx = maxHeightPx, + ) + } + } + } + } +} + +/** + * Lecteur PDF en mode focus : une page par écran (`VerticalPager`), le swipe + * vertical passe d'une page à l'autre. Chaque page est rendue pour remplir au + * mieux la zone visible (fit) à la résolution de l'écran. + */ +@Composable +fun PdfFocusViewer( + file: FileEntity, + contentResolver: ContentResolver, + onOpenExternalFailed: () -> Unit, + pagerState: PagerState, + modifier: Modifier = Modifier, + onPageCountChanged: ((Int) -> Unit)? = null, +) { + val uri = file.uri + if (uri == null) { + CloudOnlyPlaceholder(file, modifier) + return + } + + val document = remember(uri) { PdfDocumentState(contentResolver, uri) } + var pageCount by remember(uri) { mutableIntStateOf(0) } + var loadFailed by remember(uri) { mutableStateOf(false) } + + DisposableEffect(document) { + onDispose { document.close() } + } + LaunchedEffect(uri) { pageCount = 0 loadFailed = false @@ -105,20 +175,69 @@ fun PdfPageViewer( val maxWidthPx = with(density) { maxWidth.toPx().toInt() } val maxHeightPx = with(density) { maxHeight.toPx().toInt() } - LazyColumn( - state = listState, + VerticalPager( + state = pagerState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + ) { page -> + PdfFocusPageItem( + document = document, + page = page, + maxWidthPx = maxWidthPx, + maxHeightPx = maxHeightPx, + ) + } + } + } +} + +/** Page plein écran : bitmap rendu à la résolution de l'écran, fit dans la zone. */ +@Composable +private fun PdfFocusPageItem( + document: PdfDocumentState, + page: Int, + maxWidthPx: Int, + maxHeightPx: Int, +) { + var bitmap by remember(page) { mutableStateOf(null) } + var failed by remember(page) { mutableStateOf(false) } + LaunchedEffect(document, page, maxWidthPx, maxHeightPx) { + failed = false + bitmap = document.bitmap(page, maxWidthPx, maxHeightPx) + if (bitmap == null) failed = true + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + when { + bitmap != null -> Image( + bitmap = bitmap!!.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + failed -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 32.dp), ) { - items(pageCount, key = { it }) { page -> - PdfPageItem( - document = document, - page = page, - maxWidthPx = maxWidthPx, - maxHeightPx = maxHeightPx, - ) - } + Icon( + imageVector = Icons.Filled.BrokenImage, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.height(32.dp), + ) + Text( + text = stringResource(R.string.document_page_unreadable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 32.dp), + ) { + CircularProgressIndicator() } } } @@ -218,8 +337,11 @@ private class PdfDocumentState( val current = renderer ?: error("document non ouvert") val pdfPage = current.openPage(page) try { + // Ajuste le rendu à la résolution cible (passe au strict besoin), + // borné par MAX_SCALE pour ne pas exploser la mémoire des pages + // vectorielles très grandes. val scale = minOf( - 1f, + MAX_SCALE, maxWidthPx.toFloat() / pdfPage.width, maxHeightPx.toFloat() / pdfPage.height, ) @@ -242,6 +364,9 @@ private class PdfDocumentState( companion object { private const val MAX_CACHED_PAGES = 6 + + /** Borne du ratio de rendu (résolution écran) appliquée à la page source. */ + private const val MAX_SCALE = 2f } }