focus on document

This commit is contained in:
m
2026-09-12 01:33:04 +02:00
parent aa139b6d33
commit 1134ecaedb
2 changed files with 179 additions and 31 deletions
@@ -1,6 +1,11 @@
package com.vaultdrop.mobile.ui.document package com.vaultdrop.mobile.ui.document
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -8,8 +13,10 @@ 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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -21,26 +28,41 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity 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.DocumentContentViewer
import com.vaultdrop.mobile.ui.document.content.PdfPageViewer
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -52,6 +74,9 @@ import java.util.Locale
* Consultation d'un document : lecteur intégré (PDF / image / texte) ou fallback * Consultation d'un document : lecteur intégré (PDF / image / texte) ou fallback
* externe, carte de métadonnées, navigation au swipe entre les documents (même * externe, carte de métadonnées, navigation au swipe entre les documents (même
* fil que l'écran d'accueil). * fil que l'écran d'accueil).
*
* Les PDF locaux passent en mode plein écran sur tap : barres système et barre
* d'outils masquées, document verrouillé, indication de page en bas.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -86,9 +111,41 @@ fun DocumentViewerScreen(
} }
} }
var fullscreen by rememberSaveable { mutableStateOf(false) }
val canFullscreen = currentFile?.canPdfFullscreen() == true
// Mode immersif : masque les barres système en plein écran, les restaure
// à la sortie ou si l'écran est composé autrement.
val view = LocalView.current
DisposableEffect(fullscreen, canFullscreen) {
val window = (view.context as? Activity)?.window
if (window != null) {
val controller = WindowCompat.getInsetsController(window, view)
if (fullscreen && canFullscreen) {
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
} else {
controller.show(WindowInsetsCompat.Type.systemBars())
}
}
onDispose {
val restoredWindow = (view.context as? Activity)?.window
if (restoredWindow != null) {
WindowCompat.getInsetsController(restoredWindow, view)
.show(WindowInsetsCompat.Type.systemBars())
}
}
}
// En plein écran, retour système = retour à la vue classique plutôt que de
// quitter le document.
BackHandler(enabled = fullscreen && canFullscreen) { fullscreen = false }
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
if (!(fullscreen && canFullscreen)) {
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
@@ -106,8 +163,18 @@ fun DocumentViewerScreen(
} }
}, },
) )
}
}, },
) { padding -> ) { padding ->
val current = currentFile
if (fullscreen && canFullscreen && current != null) {
PdfFullscreenReader(
file = current,
onOpenExternalFailed = onOpenExternalFailed,
onExitFullscreen = { fullscreen = false },
modifier = Modifier.fillMaxSize(),
)
} else {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier modifier = Modifier
@@ -115,7 +182,68 @@ fun DocumentViewerScreen(
.padding(padding), .padding(padding),
) { page -> ) { page ->
val file = documents.getOrNull(page) val file = documents.getOrNull(page)
if (file != null) DocumentViewerPage(file = file, onOpenExternalFailed = onOpenExternalFailed) if (file != null) {
DocumentViewerPage(
file = file,
onOpenExternalFailed = onOpenExternalFailed,
onEnterFullscreen = file.canPdfFullscreen()
.takeIf { it }
?.let { { fullscreen = true } },
)
}
}
}
}
}
/**
* 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.
*/
@Composable
private fun PdfFullscreenReader(
file: FileEntity,
onOpenExternalFailed: () -> Unit,
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 } }
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(file.resourceId) {
detectTapGestures(onTap = { onExitFullscreen() })
},
) {
PdfPageViewer(
file = file,
contentResolver = contentResolver,
onOpenExternalFailed = onOpenExternalFailed,
modifier = Modifier.fillMaxSize(),
listState = listState,
onPageCountChanged = { pageCount = it },
)
if (pageCount > 0) {
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
shape = RoundedCornerShape(percent = 50),
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f),
) {
Text(
text = "$currentPage / $pageCount",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
}
} }
} }
} }
@@ -124,22 +252,32 @@ fun DocumentViewerScreen(
private fun DocumentViewerPage( private fun DocumentViewerPage(
file: FileEntity, file: FileEntity,
onOpenExternalFailed: () -> Unit, onOpenExternalFailed: () -> Unit,
onEnterFullscreen: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(16.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.then(
if (onEnterFullscreen != null) Modifier.pointerInput(onEnterFullscreen) {
detectTapGestures(onTap = { onEnterFullscreen() })
} else Modifier,
),
) { ) {
// Lecteur central : dispatch par catégorie (PDF / image / texte / // Lecteur central : dispatch par catégorie (PDF / image / texte /
// délégation externe), état dédié pour les fichiers cloud-only. // délégation externe), état dédié pour les fichiers cloud-only.
DocumentContentViewer( DocumentContentViewer(
file = file, file = file,
onOpenExternalFailed = onOpenExternalFailed, onOpenExternalFailed = onOpenExternalFailed,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxWidth()
.weight(1f),
) )
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -187,6 +325,10 @@ private fun fileTypeLabel(file: FileEntity): String =
?: file.mimeType?.uppercase(Locale.getDefault()) ?: file.mimeType?.uppercase(Locale.getDefault())
?: "" ?: ""
/** Vrai si le fichier est un PDF avec un contenu local consultable en plein écran. */
private fun FileEntity.canPdfFullscreen(): Boolean =
uri != null && categoryValue() == FileCategory.PDF
private fun formatDateTime(millis: Long): String { private fun formatDateTime(millis: Long): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()
val dateTime = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime() val dateTime = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime()
@@ -17,6 +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.lazy.rememberLazyListState
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
@@ -58,6 +60,8 @@ 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) {
@@ -80,6 +84,7 @@ fun PdfPageViewer(
loadFailed = false loadFailed = false
if (document.load()) { if (document.load()) {
pageCount = document.pageCount pageCount = document.pageCount
onPageCountChanged?.invoke(pageCount)
} else { } else {
loadFailed = true loadFailed = true
} }
@@ -101,6 +106,7 @@ fun PdfPageViewer(
val maxHeightPx = with(density) { maxHeight.toPx().toInt() } val maxHeightPx = with(density) { maxHeight.toPx().toInt() }
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),