add pdf viewer

This commit is contained in:
m
2026-09-12 09:46:09 +02:00
parent 1134ecaedb
commit e272ecfb44
2 changed files with 151 additions and 30 deletions
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -34,7 +33,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf 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.domain.FileCategory
import com.vaultdrop.mobile.ui.components.categoryValue import com.vaultdrop.mobile.ui.components.categoryValue
import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer 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 kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -197,9 +195,9 @@ fun DocumentViewerScreen(
} }
/** /**
* Lecture PDF focus : le document remplit l'écran (fond noir), un tap quitte le * Lecture PDF focus : le document remplit l'écran (fond noir), une page par
* plein écran, le défilement vertical reste actif et une pastille indique la * écran défiler verticalement, un tap quitte le plein écran et une pastille
* page courante. * indique la page courante.
*/ */
@Composable @Composable
private fun PdfFullscreenReader( private fun PdfFullscreenReader(
@@ -208,10 +206,8 @@ private fun PdfFullscreenReader(
onExitFullscreen: () -> Unit, onExitFullscreen: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val contentResolver = LocalContext.current.contentResolver
val listState = rememberLazyListState()
var pageCount by remember(file.resourceId) { mutableIntStateOf(0) } var pageCount by remember(file.resourceId) { mutableIntStateOf(0) }
val currentPage by remember { derivedStateOf { listState.firstVisibleItemIndex + 1 } } val pagerState = rememberPagerState(pageCount = { pageCount })
Box( Box(
modifier = modifier modifier = modifier
@@ -221,12 +217,12 @@ private fun PdfFullscreenReader(
detectTapGestures(onTap = { onExitFullscreen() }) detectTapGestures(onTap = { onExitFullscreen() })
}, },
) { ) {
PdfPageViewer( PdfFocusViewer(
file = file, file = file,
contentResolver = contentResolver, contentResolver = LocalContext.current.contentResolver,
onOpenExternalFailed = onOpenExternalFailed, onOpenExternalFailed = onOpenExternalFailed,
pagerState = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
listState = listState,
onPageCountChanged = { pageCount = it }, onPageCountChanged = { pageCount = it },
) )
if (pageCount > 0) { if (pageCount > 0) {
@@ -238,7 +234,7 @@ private fun PdfFullscreenReader(
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f),
) { ) {
Text( Text(
text = "$currentPage / $pageCount", text = "${pagerState.currentPage + 1} / $pageCount",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.inverseOnSurface, color = MaterialTheme.colorScheme.inverseOnSurface,
@@ -17,8 +17,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -60,8 +61,6 @@ fun PdfPageViewer(
contentResolver: ContentResolver, contentResolver: ContentResolver,
onOpenExternalFailed: () -> Unit, onOpenExternalFailed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
onPageCountChanged: ((Int) -> Unit)? = null,
) { ) {
val uri = file.uri val uri = file.uri
if (uri == null) { if (uri == null) {
@@ -79,6 +78,77 @@ fun PdfPageViewer(
onDispose { document.close() } 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) { LaunchedEffect(uri) {
pageCount = 0 pageCount = 0
loadFailed = false loadFailed = false
@@ -105,20 +175,69 @@ fun PdfPageViewer(
val maxWidthPx = with(density) { maxWidth.toPx().toInt() } val maxWidthPx = with(density) { maxWidth.toPx().toInt() }
val maxHeightPx = with(density) { maxHeight.toPx().toInt() } val maxHeightPx = with(density) { maxHeight.toPx().toInt() }
LazyColumn( VerticalPager(
state = listState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), ) { page ->
verticalArrangement = Arrangement.spacedBy(12.dp), 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<Bitmap?>(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 -> Icon(
PdfPageItem( imageVector = Icons.Filled.BrokenImage,
document = document, contentDescription = null,
page = page, tint = MaterialTheme.colorScheme.onSurfaceVariant,
maxWidthPx = maxWidthPx, modifier = Modifier.height(32.dp),
maxHeightPx = maxHeightPx, )
) 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 current = renderer ?: error("document non ouvert")
val pdfPage = current.openPage(page) val pdfPage = current.openPage(page)
try { 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( val scale = minOf(
1f, MAX_SCALE,
maxWidthPx.toFloat() / pdfPage.width, maxWidthPx.toFloat() / pdfPage.width,
maxHeightPx.toFloat() / pdfPage.height, maxHeightPx.toFloat() / pdfPage.height,
) )
@@ -242,6 +364,9 @@ private class PdfDocumentState(
companion object { companion object {
private const val MAX_CACHED_PAGES = 6 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
} }
} }