add focus mode on text and document
This commit is contained in:
+143
-30
@@ -62,7 +62,9 @@ import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
|||||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
||||||
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.ImageViewer
|
||||||
import com.vaultdrop.mobile.ui.document.content.PdfFocusViewer
|
import com.vaultdrop.mobile.ui.document.content.PdfFocusViewer
|
||||||
|
import com.vaultdrop.mobile.ui.document.content.TextFocusViewer
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -75,8 +77,9 @@ import java.util.Locale
|
|||||||
* 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
|
* Les documents locaux lisibles (PDF, image, texte) passent en mode focus sur
|
||||||
* d'outils masquées, document verrouillé, indication de page en bas.
|
* tap : barres système et barre d'outils masquées, fond noir, un tap ou le
|
||||||
|
* retour système quitte le mode.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -114,7 +117,7 @@ fun DocumentViewerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fullscreen by rememberSaveable { mutableStateOf(false) }
|
var fullscreen by rememberSaveable { mutableStateOf(false) }
|
||||||
val canFullscreen = currentFile?.canPdfFullscreen() == true
|
val canFullscreen = currentFile?.canFocus() == true
|
||||||
|
|
||||||
// Mode immersif : masque les barres système en plein écran, les restaure
|
// Mode immersif : masque les barres système en plein écran, les restaure
|
||||||
// à la sortie ou si l'écran est composé autrement.
|
// à la sortie ou si l'écran est composé autrement.
|
||||||
@@ -176,7 +179,7 @@ fun DocumentViewerScreen(
|
|||||||
) { padding ->
|
) { padding ->
|
||||||
val current = currentFile
|
val current = currentFile
|
||||||
if (fullscreen && canFullscreen && current != null) {
|
if (fullscreen && canFullscreen && current != null) {
|
||||||
PdfFullscreenReader(
|
FocusDocumentReader(
|
||||||
file = current,
|
file = current,
|
||||||
onOpenExternalFailed = onOpenExternalFailed,
|
onOpenExternalFailed = onOpenExternalFailed,
|
||||||
onExitFullscreen = { fullscreen = false },
|
onExitFullscreen = { fullscreen = false },
|
||||||
@@ -194,7 +197,7 @@ fun DocumentViewerScreen(
|
|||||||
DocumentViewerPage(
|
DocumentViewerPage(
|
||||||
file = file,
|
file = file,
|
||||||
onOpenExternalFailed = onOpenExternalFailed,
|
onOpenExternalFailed = onOpenExternalFailed,
|
||||||
onEnterFullscreen = file.canPdfFullscreen()
|
onEnterFullscreen = file.canFocus()
|
||||||
.takeIf { it }
|
.takeIf { it }
|
||||||
?.let { { fullscreen = true } },
|
?.let { { fullscreen = true } },
|
||||||
)
|
)
|
||||||
@@ -205,11 +208,84 @@ fun DocumentViewerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lecture PDF focus : le document remplit l'écran (fond noir), une page par
|
* Lecteur focus du document courant : dispatch par catégorie.
|
||||||
* écran défiler verticalement, un tap quitte le plein écran et une pastille
|
*
|
||||||
* indique la page courante.
|
* - PDF → une page par écran, défilement vertical, pastille de page
|
||||||
|
* - IMAGE → document plein écran sur fond noir
|
||||||
|
* - TEXT → lecture immersive (fond sombre, texte clair, sélectionnable)
|
||||||
|
*
|
||||||
|
* Le retour (tap ou bouton système) est géré par le cadre commun [FocusScaffold].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
|
private fun FocusDocumentReader(
|
||||||
|
file: FileEntity,
|
||||||
|
onOpenExternalFailed: () -> Unit,
|
||||||
|
onExitFullscreen: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (file.categoryValue()) {
|
||||||
|
FileCategory.PDF -> PdfFullscreenReader(
|
||||||
|
file = file,
|
||||||
|
onOpenExternalFailed = onOpenExternalFailed,
|
||||||
|
onExitFullscreen = onExitFullscreen,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
FileCategory.IMAGE -> ImageFullscreenReader(
|
||||||
|
file = file,
|
||||||
|
onExitFullscreen = onExitFullscreen,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
FileCategory.TEXT -> TextFullscreenReader(
|
||||||
|
file = file,
|
||||||
|
onExitFullscreen = onExitFullscreen,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cadre commun du mode focus : fond noir plein écran, un tap (l'indicatrice de
|
||||||
|
* position est optionnelle) quitte le mode.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FocusScaffold(
|
||||||
|
file: FileEntity,
|
||||||
|
onExitFullscreen: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
position: String? = null,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.pointerInput(file.resourceId) {
|
||||||
|
detectTapGestures(onTap = { onExitFullscreen() })
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
if (position != null) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
shape = RoundedCornerShape(percent = 50),
|
||||||
|
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = position,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lecture PDF focus : une page par écran, swipe vertical, pastille de page. */
|
||||||
|
@Composable
|
||||||
private fun PdfFullscreenReader(
|
private fun PdfFullscreenReader(
|
||||||
file: FileEntity,
|
file: FileEntity,
|
||||||
onOpenExternalFailed: () -> Unit,
|
onOpenExternalFailed: () -> Unit,
|
||||||
@@ -219,13 +295,11 @@ private fun PdfFullscreenReader(
|
|||||||
var pageCount by remember(file.resourceId) { mutableIntStateOf(0) }
|
var pageCount by remember(file.resourceId) { mutableIntStateOf(0) }
|
||||||
val pagerState = rememberPagerState(pageCount = { pageCount })
|
val pagerState = rememberPagerState(pageCount = { pageCount })
|
||||||
|
|
||||||
Box(
|
FocusScaffold(
|
||||||
modifier = modifier
|
file = file,
|
||||||
.fillMaxSize()
|
onExitFullscreen = onExitFullscreen,
|
||||||
.background(Color.Black)
|
modifier = modifier,
|
||||||
.pointerInput(file.resourceId) {
|
position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null,
|
||||||
detectTapGestures(onTap = { onExitFullscreen() })
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
PdfFocusViewer(
|
PdfFocusViewer(
|
||||||
file = file,
|
file = file,
|
||||||
@@ -235,22 +309,59 @@ private fun PdfFullscreenReader(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onPageCountChanged = { pageCount = it },
|
onPageCountChanged = { pageCount = it },
|
||||||
)
|
)
|
||||||
if (pageCount > 0) {
|
}
|
||||||
Surface(
|
}
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
/** Image focus : l'image remplit l'écran (fit) sur fond noir. */
|
||||||
.padding(bottom = 16.dp),
|
@Composable
|
||||||
shape = RoundedCornerShape(percent = 50),
|
private fun ImageFullscreenReader(
|
||||||
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f),
|
file: FileEntity,
|
||||||
|
onExitFullscreen: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val contentResolver = LocalContext.current.contentResolver
|
||||||
|
val uri = file.uri
|
||||||
|
if (uri == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusScaffold(
|
||||||
|
file = file,
|
||||||
|
onExitFullscreen = onExitFullscreen,
|
||||||
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Text(
|
ImageViewer(
|
||||||
text = "${pagerState.currentPage + 1} / $pageCount",
|
contentResolver = contentResolver,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
uri = uri,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
contentDescription = file.name,
|
||||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texte focus : lecture immersive fond sombre, texte clair, sélectionnable. */
|
||||||
|
@Composable
|
||||||
|
private fun TextFullscreenReader(
|
||||||
|
file: FileEntity,
|
||||||
|
onExitFullscreen: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val contentResolver = LocalContext.current.contentResolver
|
||||||
|
val uri = file.uri
|
||||||
|
if (uri == null) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FocusScaffold(
|
||||||
|
file = file,
|
||||||
|
onExitFullscreen = onExitFullscreen,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
TextFocusViewer(
|
||||||
|
contentResolver = contentResolver,
|
||||||
|
uri = uri,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,9 +442,11 @@ 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. */
|
/** Vrai si le fichier est local et appartient à une catégorie consultable en mode focus. */
|
||||||
private fun FileEntity.canPdfFullscreen(): Boolean =
|
private fun FileEntity.canFocus(): Boolean =
|
||||||
uri != null && categoryValue() == FileCategory.PDF
|
uri != null && categoryValue() in FOCUS_CATEGORIES
|
||||||
|
|
||||||
|
private val FOCUS_CATEGORIES = setOf(FileCategory.PDF, FileCategory.IMAGE, FileCategory.TEXT)
|
||||||
|
|
||||||
private fun formatDateTime(millis: Long): String {
|
private fun formatDateTime(millis: Long): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
|
|||||||
+68
@@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
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.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vaultdrop.mobile.R
|
import com.vaultdrop.mobile.R
|
||||||
@@ -90,6 +91,73 @@ fun TextDocumentViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lecture texte en mode focus : fond sombre, texte clair, défilement vertical.
|
||||||
|
* Reprend la lecture bornée (bandeau de troncature) et la sélection de texte
|
||||||
|
* du mode classique, avec les couleurs inversées sur fond noir.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TextFocusViewer(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var text by remember(uri) { mutableStateOf<String?>(null) }
|
||||||
|
var truncated by remember(uri) { mutableStateOf(false) }
|
||||||
|
var failed by remember(uri) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(uri) {
|
||||||
|
text = null
|
||||||
|
truncated = false
|
||||||
|
failed = false
|
||||||
|
val result = withContext(Dispatchers.IO) { readCapped(contentResolver, uri) }
|
||||||
|
result?.let {
|
||||||
|
text = it.first
|
||||||
|
truncated = it.second
|
||||||
|
} ?: run { failed = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier.fillMaxSize()) {
|
||||||
|
when {
|
||||||
|
failed -> Text(
|
||||||
|
text = stringResource(R.string.document_cannot_read),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
text == null -> Text(
|
||||||
|
text = stringResource(R.string.document_loading),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
else -> Column(Modifier.fillMaxSize()) {
|
||||||
|
if (truncated) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.document_truncated_notice),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color(0xFFFFD54F),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SelectionContainer(Modifier.weight(1f)) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = text.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Lit le flux UTF-8 borné à MAX_BYTES (+1 pour détecter la troncature). */
|
/** Lit le flux UTF-8 borné à MAX_BYTES (+1 pour détecter la troncature). */
|
||||||
private fun readCapped(contentResolver: ContentResolver, uri: String): Pair<String, Boolean>? {
|
private fun readCapped(contentResolver: ContentResolver, uri: String): Pair<String, Boolean>? {
|
||||||
val stream = DocumentContent.openInputStream(contentResolver, uri) ?: return null
|
val stream = DocumentContent.openInputStream(contentResolver, uri) ?: return null
|
||||||
|
|||||||
Reference in New Issue
Block a user