add pdf viewer
This commit is contained in:
+9
-13
@@ -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,
|
||||||
|
|||||||
+142
-17
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user